package edu.hawaii.myisern.brown.model;

import static java.util.Arrays.asList;
import static java.util.Collections.emptyMap;
import static org.apache.commons.beanutils.BeanUtils.getSimpleProperty;
import static org.apache.commons.beanutils.BeanUtils.populate;
import static org.apache.commons.beanutils.PropertyUtils.describe;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import javax.xml.bind.JAXBContext;
import javax.xml.bind.JAXBException;
import javax.xml.bind.Marshaller;
import edu.hawaii.myisern.collaborations.jaxb.Collaboration;
import edu.hawaii.myisern.collaborations.jaxb.Collaborations;
import edu.hawaii.myisern.organizations.jaxb.Organization;
import edu.hawaii.myisern.organizations.jaxb.Organizations;
import edu.hawaii.myisern.researchers.jaxb.Researcher;
import edu.hawaii.myisern.researchers.jaxb.Researchers;

/**
 * Data Model for the MyIsern program.
 * 
 * Provides access to researchers, organizations, and collaborations. Also includes methods for
 * loading data from files and checking for dangling references between entities.
 * 
 * @author Brian Jaress
 * 
 */
public class MyIsernModel {

  // containers for the data
  private Set<String> ids;
  private Map<String, Researcher> researchers;
  private Map<String, Organization> organizations;
  private Map<String, Collaboration> collaborations;

  // files to load and save from
  private String researchersFile;
  private String organizationsFile;
  private String collaborationsFile;

  /**
   * Initializes a new, empty model.
   */
  private MyIsernModel() {
    this.ids = new HashSet<String>();
    this.researchers = new HashMap<String, Researcher>();
    this.organizations = new HashMap<String, Organization>();
    this.collaborations = new HashMap<String, Collaboration>();

    String base = System.getProperty("user.home") + "/.myisern/";
    this.collaborationsFile = base + "collaborations.xml";
    this.organizationsFile = base + "organizations.xml";
    this.researchersFile = base + "researchers.xml";

    try {
      File dir = new File(base);
      if (dir.exists()) {
        this.load();
      }
      else {
        dir.mkdir();
        this.save();
      }
    }
    catch (Exception e) {
      System.err.println(e);
      System.err.println(e.getMessage());
    }
  }

  // singleton instance
  private static MyIsernModel instance = new MyIsernModel();

  /**
   * Retrieve the only instance of MyIsernModel.
   * 
   * @return the singleton instance of MyIsernModel
   */
  public static synchronized MyIsernModel getInstance() {
    return instance;
  }

  /**
   * Convert a name into an ID
   * 
   * @param name A name string
   * @return the ID
   */
  public static synchronized String makeId(String name) {
    return name.replaceAll(" ", "_");
  }

  /**
   * Return a Set of all IDs in use.
   * 
   * @return a Set of IDs
   */
  public synchronized Set<String> getIds() {
    return this.ids;
  }

  /**
   * Turns a map of beans into a map of names.
   * 
   * @param <B> a bean class with a "name" property
   * @param map a map of IDs to beans of type B
   * @return a map of IDs to names
   */
  private <B> Map<String, String> nameMap(Map<String, B> map) {
    Map<String, String> names = new HashMap<String, String>();
    for (Map.Entry<String, B> entry : map.entrySet()) {
      try {
        names.put(entry.getKey(), getSimpleProperty(entry.getValue(), "name"));
      }
      catch (Exception e) {
        names.put(entry.getKey(), "NO NAME");
      }
    }
    return names;
  }

  /**
   * Return a map of current Researchers with IDs as keys and names as values.
   * 
   * @return a map of researcher names
   */
  public synchronized Map<String, String> getResearchers() {
    return nameMap(this.researchers);
  }

  /**
   * Return a map of current Organizations with IDs as keys and names as values.
   * 
   * @return a map of organization names
   */
  public synchronized Map<String, String> getOrganizations() {
    return nameMap(this.organizations);
  }

  /**
   * Return a map of current Collaborations with IDs as keys and names as values.
   * 
   * @return a map of collaboration names
   */
  public synchronized Map<String, String> getCollaborations() {
    return nameMap(this.collaborations);
  }

  /**
   * Complete all dangling references and sync both sides of double-sided relationships.
   * 
   * Organization records trump researcher records, so that if the UH record says that Phil Johnson
   * is affiliated, but the Phil Johnson record says his organization if UCXZ, Johnson ends up in
   * UH.
   */
  private void complete() {

    // collab reference to organization
    for (Collaboration c : this.collaborations.values()) {
      if (c.getCollaboratingOrganizations() == null) {
        continue;
      }
      for (String name : c.getCollaboratingOrganizations()) {

        // force org to exist
        if (!this.organizations.containsKey(makeId(name))) {
          Organization o = new Organization();
          o.setName(name);
          try {
            storeOrganization(o);
          }
          catch (Exception e) {
            continue;
          }
        }
      }
    }

    // org reference to researcher
    for (Organization o : this.organizations.values()) {
      if (o.getAffiliatedResearchers() == null) {
        continue;
      }
      for (String name : o.getAffiliatedResearchers()) {
        Researcher r;
        String resId = makeId(name);

        // force researcher to exist
        if (this.researchers.containsKey(resId)) {
          r = this.researchers.get(resId);
        }
        else {
          r = new Researcher();
          r.setName(name);
          try {
            storeResearcher(r);
          }
          catch (Exception e) {
            continue;
          }
        }

        // force researcher into org
        r.setOrganization(o.getName());
        storeResearcher(r);
      }
    }

    // researcher reference to organization
    for (Researcher r : this.researchers.values()) {
      String name = r.getOrganization();
      String orgId = makeId(name);
      Organization o;
      // force org to exist
      if (this.organizations.containsKey(orgId)) {
        o = this.organizations.get(orgId);
      }
      else {
        o = new Organization();
        o.setName(name);
        try {
          storeOrganization(o);
        }
        catch (Exception e) {
          continue;
        }
      }
      // force researcher into org
      if (!o.getAffiliatedResearchers().contains(r.getName())) {
        o.getAffiliatedResearchers().add(r.getName());
        storeOrganization(o);
      }
    }
  }

  /**
   * Add a collaboration.
   * 
   * @param c the collaboration
   * @throws IOException if there is already another entity with the same ID
   */
  private void addCollaboration(Collaboration c) throws IOException {
    String id = makeId(c.getName());
    if (this.ids.contains(id) || this.collaborations.containsKey(id)) {
      throw new IOException("Duplicate ID: " + id);
    }
    else {
      storeCollaboration(c);
    }
  }

  /**
   * Load data from files.
   * 
   * @throws JAXBException if it cannot create a parser
   * @throws IOException on bad input
   */
  public synchronized void load() throws JAXBException, IOException {
    this.ids.clear();
    try {
      // researchers
      this.researchers.clear();
      for (Researcher r : ((Researchers) JAXBContext.newInstance(Researchers.class)
          .createUnmarshaller().unmarshal(new File(this.researchersFile))).getResearcher()) {
        addResearcher(r);
      }

      // organizations
      this.organizations.clear();
      for (Organization o : ((Organizations) JAXBContext.newInstance(Organizations.class)
          .createUnmarshaller().unmarshal(new File(this.organizationsFile))).getOrganization()) {
        addOrganization(o);
      }

      // collaborations
      this.collaborations.clear();
      for (Collaboration c : ((Collaborations) JAXBContext.newInstance(Collaborations.class)
          .createUnmarshaller().unmarshal(new File(this.collaborationsFile))).getCollaboration()) {
        addCollaboration(c);
      }
    }
    catch (IllegalArgumentException e) {
      throw new IOException(e);
    }
    this.complete();
  }

  /**
   * Add an organization.
   * 
   * @param o the organization
   * @throws IOException if there is already another entity with the same ID
   */
  public synchronized void addOrganization(Organization o) throws IOException {
    String id = makeId(o.getName());
    if (this.ids.contains(id) || this.organizations.containsKey(id)) {
      throw new IOException("Duplicate ID: " + id);
    }
    else {
      storeOrganization(o);
    }
  }

  /**
   * Add a researcher
   * 
   * @param r the researcher
   * @throws IOException if there is already another entity with the same ID
   */
  public synchronized void addResearcher(Researcher r) throws IOException {
    String id = makeId(r.getName());
    if (this.ids.contains(id) || this.researchers.containsKey(id)) {
      throw new IOException("Duplicate ID: " + id);
    }
    else {
      storeResearcher(r);
    }
  }

  /**
   * Comparisons for organization collaboration levels
   * 
   * @see #getOrganizationsByCollaborationLevel(CollabLevelCompare, int)
   */
  public enum CollabLevelCompare {
    /** Same as given level */
    EQUAL() {
      /**
       * Equality test
       * 
       * @param a left-hand side
       * @param b right-hand side
       * @return true if equal
       */
      boolean isMatch(int a, int b) {
        return a == b;
      }
    },

    /** Strictly greater than given level */
    GREATER() {
      /**
       * Greater-than test
       * 
       * @param a left-hand side
       * @param b right-hand side
       * @return true if strictly greater than
       */
      boolean isMatch(int a, int b) {
        return a > b;
      }
    };

    /**
     * Check for a match between an organization's collaboration level and a target level.
     * 
     * @param orgLevel the collaboration level of the organization
     * @param targetLevel the target level
     * @return true if there is a match
     */
    abstract boolean isMatch(int orgLevel, int targetLevel);
  }

  /**
   * Get organizations by collaborations level
   * 
   * @param comp the type of comparison that determines whether an organization's collaboration
   * level matches what we're looking for
   * @param target the target level
   * @return a map of matching organizations
   */
  public synchronized Map<String, String> getOrganizationsByCollaborationLevel(
      CollabLevelCompare comp, int target) {
    Map<String, Integer> levels = new HashMap<String, Integer>();

    // check the level of each org
    for (Collaboration c : this.collaborations.values()) {
      for (String name : c.getCollaboratingOrganizations()) {
        String id = makeId(name);
        if (levels.containsKey(id)) {
          levels.put(id, 1 + levels.get(id));
        }
        else {
          levels.put(id, 1);
        }
      }
    }

    // find matches
    Map<String, Organization> matches = new HashMap<String, Organization>();
    for (String id : this.organizations.keySet()) {
      if (comp.isMatch(levels.containsKey(id) ? levels.get(id) : 0, target)) {
        matches.put(id, this.organizations.get(id));
      }
    }

    return nameMap(matches);
  }

  /**
   * Get collaborations by organization
   * 
   * @param orgId the organization to look for
   * @return a map of collaborations involving the organization
   */
  public synchronized Map<String, String> getCollaborationsByOrganization(String orgId) {
    return filterCollabs(CollabFilter.ORG_ID, orgId);
  }

  /**
   * Get collaborations by year
   * 
   * @param year the year to look for
   * @return a map of collaborations that were happening in the given year
   */
  public synchronized Map<String, String> getCollaborationsByYear(int year) {
    return filterCollabs(CollabFilter.YEAR, Integer.toString(year));
  }

  /**
   * Get collaborations by researcher
   * 
   * @param researcherId the ID of the researcher to look for
   * @return a map of collaborations involving the organization to which the researcher belongs.
   */
  public synchronized Map<String, String> getCollaborationsByResearcher(String researcherId) {
    if (this.researchers.containsKey(researcherId)) {
      return getCollaborationsByOrganization(makeId(this.researchers.get(researcherId)
          .getOrganization()));
    }
    else {
      return emptyMap();
    }
  }

  /**
   * Different ways of filtering the the Collaborations
   * 
   * This is just an internal helper class.
   */
  enum CollabFilter {
    ORG_ID {
      /**
       * Check if a collaboration matches the target organization
       * 
       * @param c the collaboration
       * @param target the target
       * @return true if it matches
       */
      boolean isMatch(Collaboration c, String target) {
        boolean match = false;
        for (String name : c.getCollaboratingOrganizations()) {
          if (makeId(name).equals(target)) {
            match = true;
          }
        }
        return match;
      }
    },

    YEAR {
      /**
       * Check if a collaboration matches the target year
       * 
       * @param c the collaboration
       * @param target the target
       * @return true if it matches
       */
      boolean isMatch(Collaboration c, String target) {
        boolean match = false;
        for (BigInteger year : c.getYears()) {
          if (year.equals(new BigInteger(target))) {
            match = true;
          }
        }
        return match;
      }
    };

    /**
     * Check if a collaboration matches the target
     * 
     * @param c the collaboration
     * @param target the target
     * @return true if it matches
     */
    abstract boolean isMatch(Collaboration c, String target);
  }

  /**
   * Applies the given filter to create a map of matching Collaborations.
   * 
   * @param filter the filter
   * @param target the filter parameter
   * @return a map of all matching filters
   */
  private Map<String, String> filterCollabs(CollabFilter filter, String target) {
    Map<String, Collaboration> matches = new HashMap<String, Collaboration>();

    for (Map.Entry<String, Collaboration> entry : this.collaborations.entrySet()) {
      if (filter.isMatch(entry.getValue(), target)) {
        matches.put(entry.getKey(), entry.getValue());
      }
    }
    return nameMap(matches);
  }

  /**
   * Save the data to files
   * 
   * @throws IOException on save error
   * @throws JAXBException if it cannot convert the objects to XML
   */
  public synchronized void save() throws IOException, JAXBException {
    Marshaller marshaller;

    // Needed to test simple creation of organizations w/o researchers - randy
    // this.complete();
    // researchers
    marshaller = JAXBContext.newInstance(Researchers.class).createMarshaller();
    Researchers researchers = new Researchers();
    researchers.getResearcher().addAll(this.researchers.values());
    marshaller.marshal(researchers, new FileOutputStream(this.researchersFile));

    // organizations
    marshaller = JAXBContext.newInstance(Organizations.class).createMarshaller();
    Organizations organizations = new Organizations();
    organizations.getOrganization().addAll(this.organizations.values());
    marshaller.marshal(organizations, new FileOutputStream(this.organizationsFile));

    // collaborations
    marshaller = JAXBContext.newInstance(Collaborations.class).createMarshaller();
    Collaborations collaborations = new Collaborations();
    collaborations.getCollaboration().addAll(this.collaborations.values());
    marshaller.marshal(collaborations, new FileOutputStream(this.collaborationsFile));
  }

  /**
   * @param researchersFile the researchersFile to set
   */
  public synchronized void setResearchersFile(String researchersFile) {
    this.researchersFile = researchersFile;
  }

  /**
   * @param organizationsFile the organizationsFile to set
   */
  public synchronized void setOrganizationsFile(String organizationsFile) {
    this.organizationsFile = organizationsFile;
  }

  /**
   * 
   * @param collaborationsFile the collaborationsFile to set
   */
  public synchronized void setCollaborationsFile(String collaborationsFile) {
    this.collaborationsFile = collaborationsFile;
  }

  /**
   * Store a researcher's data.
   * 
   * See the XML schema for what data is expected.
   * 
   * @param bean a bean containing the data
   */
  public synchronized void storeResearcher(Object bean) {
    store(this.researchers, new Researcher(), bean);
  }

  /**
   * Store an organization's data.
   * 
   * See the XML schema for what data is expected.
   * 
   * @param bean a bean containing the data
   */
  public synchronized void storeOrganization(Object bean) {
    store(this.organizations, new Organization(), bean);
  }

  /**
   * Store a collaborations's data.
   * 
   * See the XML schema for what data is expected.
   * 
   * @param bean a bean containing the data
   */
  public synchronized void storeCollaboration(Object bean) {
    store(this.collaborations, new Collaboration(), bean);
  }

  /**
   * Store data from a bean in a record.
   * 
   * @param <R> The type of the record to store
   * @param map the map of records to put it in
   * @param newRecord a blank record to use
   * @param bean the bean containing the data
   */
  private <R> void store(Map<String, R> map, R newRecord, Object bean) {
    String id = "";
    try {
      separateCopy(newRecord, bean);
      id = makeId(getSimpleProperty(newRecord, "name"));
    }
    catch (Exception e) {
      throw new IllegalArgumentException(e);
    }
    if (this.ids.contains(id) && !map.containsKey(id)) {
      throw new IllegalArgumentException(
          "ID used by other record type: " + id);
    }
    validate(newRecord);
    this.ids.add(id);
    map.put(id, newRecord);
  }

  /**
   * Fill a bean with information on a researcher.
   * 
   * See the XML schema for what data is available.
   * 
   * @param bean a bean to put the data in
   * @param researcherId the ID of the researcher to get the data from
   */
  public synchronized void fillResearcher(Object bean, String researcherId) {
    fill(bean, researcherId, this.researchers);
  }

  /**
   * Fill a bean with information on an organization.
   * 
   * See the XML schema for what data is available.
   * 
   * @param bean a bean to put the data in
   * @param organizationId the ID of the organization to get the data from
   */
  public synchronized void fillOrganization(Object bean, String organizationId) {
    fill(bean, organizationId, this.organizations);
  }

  /**
   * Fill a bean with information on a collaboration.
   * 
   * See the XML schema for what data is available.
   * 
   * @param bean a bean to put the data in
   * @param collaborationId the ID of the collaboration to get the data from
   */
  public synchronized void fillCollaboration(Object bean, String collaborationId) {
    fill(bean, collaborationId, this.collaborations);
  }

  /**
   * Fill a bean with data
   * 
   * @param <B> a bean class
   * @param bean a bean to put the data in
   * @param id the ID of the object with the data
   * @param map a map containing the object with the data
   */
  private <B> void fill(Object bean, String id, Map<String, B> map) {
    if (!map.containsKey(id)) {
      throw new IllegalArgumentException("ID not found: " + id);
    }
    separateCopy(bean, map.get(id));
  }

  /**
   * Validate a record
   * 
   * @param record a record to validate
   * @throws IllegalArgumentException if the record is invalid
   */
  private static void validate(Object record) {
    IllegalArgumentException e = new IllegalArgumentException();

    try {
      if (getSimpleProperty(record, "name").trim().equals("")) {
        throw e;
      }
    }
    catch (Exception any) {
      throw e;
    }

    if (record.getClass().getCanonicalName().equals(Collaboration.class.getCanonicalName())) {
      Collaboration c = (Collaboration) record;
      if (c.getYears().size() == 0) {
        throw new IllegalArgumentException("Missing years:" + c.getName());
      }
      for (BigInteger year : c.getYears()) {
        if (year.longValue() > 2010 || year.longValue() < 1990) {
          throw e;
        }
      }
    }
  }

  /**
   * Copy data from one bean to another, keeping lists separate.
   * 
   * Beanutils has a copyProperties method, but those copies share list properties. It also has
   * getProperties and setProperties, but getProperties seems to only ever return Strings.
   * 
   * @param dest the destination bean
   * @param source the source bean
   * @throws IllegalArgumentException if the beans are not compatible
   */
  @SuppressWarnings("unchecked")
  private static void separateCopy(Object dest, Object source) {
    try {
      Map m = describe(source);
      for (Object o : m.keySet()) {
        if (m.get(o) instanceof List) {
          List l = (List) m.get(o).getClass().newInstance();
          l.addAll((List) m.get(o));
          m.put(o, l);
        }
      }
      populate(dest, m);
    }
    catch (Exception e) {
      throw new IllegalArgumentException(e);
    }
  }

  /**
   * Produce a report on dangling references and missing information.
   * 
   * @return a list of lists of strings representing the report in table format
   */
  public synchronized List<List<String>> getDataIntegrityReport() {
    List<List<String>> report = new ArrayList<List<String>>();
    report.addAll(findDangling());
    report.addAll(findErrors(this.collaborations));
    report.addAll(findErrors(this.organizations));
    report.addAll(findErrors(this.researchers));
    return report;
  }

  /**
   * Find blank values and empty lists.
   * 
   * @param <T> Some type
   * @param map a map of IDs to objects of type T
   * @return a list of lists of strings representing a table of fields found to be blank
   */
  @SuppressWarnings("unchecked")
  private <T> List<List<String>> findErrors(Map<String, T> map) {
    List<List<String>> errors = new ArrayList<List<String>>();
    for (Map.Entry<String, T> record : map.entrySet()) {
      Map<?, ?> recordDescription;
      try {
        recordDescription = describe(record.getValue());
      }
      catch (Exception e) {
        continue;
      }
      for (String field : (Set<String>) recordDescription.keySet()) {
        String err = "";
        Object value = recordDescription.get(field);
        if (value instanceof List && ((List<?>) value).size() == 0) {
          err = "Missing data";
        }
        else if (value == null || (value instanceof String && ((String) value).trim().equals(""))) {
          err = "Blank field";
        }
        if (!"".equals(err)) {
          errors.add(asList(record.getKey(), err, field.toString(), record.getValue().getClass()
              .getName()));
        }
      }
    }
    return errors;
  }

  /**
   * Find dangling references.
   * 
   * More bogus code we wouldn't have to write if we were using a database.
   * 
   * @return a list of lists representing a table of dangling references found
   */
  private List<List<String>> findDangling() {
    List<List<String>> dangling = new ArrayList<List<String>>();
    final String msg = "msg: ";
    // collab reference to organization
    for (Collaboration c : this.collaborations.values()) {
      for (String name : c.getCollaboratingOrganizations()) {
        if (!this.organizations.containsKey(makeId(name))) {
          dangling
              .add(asList(makeId(c.getName()), msg + makeId(name), "collaboratingOrganizations"));
        }
      }
    }

    // org reference to researcher
    for (Organization o : this.organizations.values()) {
      for (String name : o.getAffiliatedResearchers()) {
        String resId = makeId(name);

        if (!this.researchers.containsKey(resId)) {
          dangling.add(asList(makeId(o.getName()), msg + resId, "affiliatedResearchers"));
        }
      }
    }

    // researcher reference to organization
    for (Researcher r : this.researchers.values()) {
      String name = r.getOrganization();
      String orgId = makeId(name);
      // force org to exist
      if (!this.organizations.containsKey(orgId)) {
        dangling.add(asList(makeId(r.getName()), msg + "orgId", "organization"));
      }

    }

    return dangling;
  }

  /**
   * Build comma delimited string.
   * @param list list of strings
   * @return comma delimited string.
   */
  private String buildCommaStr(List<String> list) {
    String str = "";
    for (String name : list) {
      if (!str.isEmpty()) {
        str = str.concat(", ");
      }
      str = str.concat(name);
    }
    return str;
  }
  
  /**
   * Build comma delimited string.
   * @param list list of strings
   * @return comma delimited string.
   */
  private String buildCommaStrBigInteger(List<BigInteger> list) {
    String str = "";
    for (BigInteger i : list) {
      if (!str.isEmpty()) {
        str = str.concat(", ");
      }
      str = str.concat(i.toString());
    }
    return str;
  }
  
  /**
   * Get a table representation of the researcher data.
   * 
   * @return a list of lists representing the researcher data.
   */
  public synchronized List<List<String>> getResearcherTable() {
    List<List<String>> table = new ArrayList<List<String>>();
    for (Researcher r : this.researchers.values()) {
      List<String> line = new ArrayList<String>();
      line.add(r.getName());
      line.add(r.getEmail());
      line.add(r.getOrganization());
      line.add(r.getBioStatement());
      line.add(r.getPictureLink());
      table.add(line);
    }
    return table;
  }

  /**
   * Get a table representation of the organization data.
   * 
   * @return a list of lists representing the organization data.
   */
  public synchronized List<List<String>> getOrganizationTable() {
    List<List<String>> table = new ArrayList<List<String>>();
    for (Organization o : this.organizations.values()) {
      List<String> line = new ArrayList<String>();
      line.add(o.getName());
      line.add(o.getContact());
      line.add(o.getHomePage());
      line.add(o.getCountry());
      line.add(o.getType());
      line.add(buildCommaStr(o.getAffiliatedResearchers()));
      line.add(o.getResearchDescription());
      line.add(buildCommaStr(o.getResearchKeywords()));
      table.add(line);
    }
    return table;
  }

  /**
   * Get a table representation of the collaboration data.
   * 
   * @return a list of lists representing the collaboration data.
   */
  public synchronized List<List<String>> getCollaborationTable() {
    List<List<String>> table = new ArrayList<List<String>>();
    for (Collaboration c : this.collaborations.values()) {
      List<String> line = new ArrayList<String>();
      line.add(c.getName());
      line.add(c.getDescription());
      line.add(buildCommaStr(c.getCollaboratingOrganizations()));
      line.add(buildCommaStr(c.getCollaborationTypes()));
      line.add(buildCommaStr(c.getOutcomeTypes()));
      line.add(buildCommaStrBigInteger(c.getYears()));
      table.add(line);
    }
    return table;
  }

}
